iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0

前端能直接連資料庫時,資料庫的權限規則就是你唯一的一道牆。

大綱

  • 情境:一行 curl,拿走整張會員資料表(2025 年 Lovable 事件)
  • 「前端直接連資料庫」是什麼意思
  • RLS 和 Security Rules:每一列資料的門禁
  • 常見的三種沒蓋好的牆
  • 30 秒自我檢查
  • 提示詞:盤點每一張表的權限規則
  • 驗證:用公開金鑰直接查資料庫,以及「成功但什麼都沒做」的陷阱
  • 機制:PostgREST、兩層權限(grant 與 RLS)、USINGWITH CHECK
  • Supabase 2026 年的預設值變更
  • RLS 常見錯誤清單
  • 程式碼:RLS policy
  • 對照:應用層的多租戶隔離(Laravel TenantScope),以及預設值的選擇
  • 測試:Supabase 兩個使用者的自動化測試、Laravel 繞道盤點測試
  • 對應標準
  • 攔截點:該在哪個 SSDLC 階段攔下、要問的問題
  • 劃重點

【情境】

某個 AI 生成的服務用 Supabase 當資料庫。前端程式碼裡有 Supabase 的網址和公開金鑰,這很正常,Supabase 本來就這樣設計(第 3 天〈瀏覽器看得到的,全都是公開的〉)。

但有人把這兩個值複製出來,在自己的電腦上執行:

curl "https://xxxx.supabase.co/rest/v1/profiles?select=*" \
  -H "apikey: <公開金鑰>" \
  -H "Authorization: Bearer <公開金鑰>"

回傳的是整張會員資料表

原因是那張表沒有開啟 RLS(Supabase 內建的資料權限規則,下面說明)。

這不是假設的情境。2025 年 3 月,研究者 Matt Palmer 掃描了 1,645 個用 AI 建站工具 Lovable 做出來的網站,其中 170 個(約 10.3%)有這個問題,共 303 個 API endpoint 可以直接讀寫資料,外洩的內容包括姓名、email、付款紀錄,以及開發者存在資料庫裡的第三方服務 API 金鑰。這個漏洞編號是 CVE-2025-48757。

Lovable 對這個編號提出異議,理由是:保護應用程式的資料,是每個使用 Lovable 的客戶自己的責任。不管你同不同意這個說法,結果都一樣:這道牆要你自己蓋

「前端直接連資料庫」是什麼意思

傳統的網站架構是:前端 → 你的後端程式 → 資料庫。後端程式負責檢查「這個人能不能拿這筆資料」。

Supabase、Firebase 這類服務讓你省掉後端:前端直接跟資料庫說話。這讓開發變得超快,AI 生成的專案也常直接用這種架構。

代價是:原本後端做的檢查,現在全部要由資料庫的權限規則來做

每一列資料的門禁

  • Supabase 的叫做 RLS(Row Level Security)
  • Firebase 的叫做 Security Rules

想像資料庫是一棟公寓,每一列資料是一個房間。沒有設定規則,就是所有房門都沒鎖,拿到大樓地址(公開金鑰)的人都能進每一間。

常見的三種沒蓋好的牆

  1. 根本沒開:資料表建好了,RLS 沒開啟。用 SQL 建表時(AI 通常這樣建),RLS 預設是關的。
  2. 開了但全部放行:規則寫成「任何人都可以讀寫」,常見於開發初期「先讓它動起來」,後來忘了改。Firebase 的「測試模式」就是這種規則:建立資料庫時選測試模式,預設規則是 allow read, write: if request.time < timestamp.date(...),也就是 30 天內任何人都能讀寫,30 天後全部拒絕。到期那天服務突然壞掉,最省事的修法是直接刪掉日期條件,資料庫從此永久對外開放。
  3. 只擋了讀,沒擋寫:別人看不到你的資料,但新增資料的規則只檢查「有沒有登入」,沒檢查「資料擁有者是不是你自己」。結果任何會員都能新增一筆「擁有者是別人」的資料,塞進別人的清單裡。

30 秒自我檢查

Supabase

  1. 打開後台的 Table Editor,看資料表名稱旁邊有沒有紅色的 Unrestricted 標籤。有的話,那張表沒開 RLS。
  2. 打開 Advisors → Security Advisor,看有沒有 rls_disabled_in_public(沒開 RLS)或 security_definer_view(view 會繞過 RLS,下面說明)這類錯誤。

想用 SQL 確認的話,在 SQL Editor 執行這段,列出的就是沒開 RLS 的表:

select c.relname as table_without_rls
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'r'
  and not c.relrowsecurity;

Firebase:到 Firestore 或 Realtime Database 的「規則」分頁,搜尋三樣東西:

  • if true:所有人都能讀寫
  • request.time <:測試模式的規則還沒換掉
  • 單獨出現的 request.auth != null:只要登入就能讀寫所有人的資料。Firebase 官方文件也提醒,看到這個條件要確認你真的想讓每個登入的人都有權限。

貼給 AI 的提示詞

這個專案讓前端直接存取資料庫。請幫我盤點資料庫的權限規則,先不要修改:

1. 列出所有資料表(或集合),每一張標出是否啟用了 RLS(或 Security Rules)
2. 每一張表,分別說明「查詢、新增、修改、刪除」四種動作的規則是什麼,用白話說明誰可以做
3. 標出任何「所有人都能執行」或「只要登入就能執行」的規則
4. 標出「可以新增或修改資料,但沒有檢查資料擁有者欄位」的規則
5. 列出所有 view,標出沒有設定 security_invoker = true 的
6. 找出任何在前端程式碼中使用了「會繞過權限規則的高權限金鑰」的地方
   (Supabase 的 secret key,sb_secret_ 開頭,或舊版的 service_role key)

修正時,把第 4 天〈登入了,不代表有權限〉的權限表一起給 AI,請它照表寫規則。

怎麼確認真的修好了

請 AI 幫你寫一支測試腳本(或自己照著做):

請寫一支測試腳本,只使用前端的公開金鑰、不登入,嘗試:
1. 查詢每一張資料表的全部資料
2. 新增一筆資料
然後再用使用者 A 登入,嘗試查詢、新增、修改、刪除使用者 B 的資料。
每一個修改或刪除的嘗試做完後,用管理權限重新查一次,確認 B 的資料真的沒有變。
把每一個嘗試的結果列成表格。

除了真正公開的資料,所有嘗試都應該失敗或回傳空的結果

最後那個「重新查一次」不能省。RLS 擋下查詢、修改、刪除時,通常不會回傳錯誤:我在本機實測,被擋下的修改一樣回傳 HTTP 200,內容是空陣列,意思是「成功執行了,但一筆都沒改到」。真的改到和被擋下,都是 HTTP 200、沒有錯誤,只看狀態碼或錯誤訊息分不出來。

機制

Supabase 透過 PostgREST 把 Postgres 的 schema 直接變成 REST API。請求帶的金鑰和登入狀態,決定它用哪個 Postgres 角色執行:

請求帶的東西 Postgres 角色 RLS
publishable key(sb_publishable_ 開頭,舊稱 anon key),沒登入 anon 受 RLS 限制
publishable key,使用者已登入(帶 JWT) authenticatedauth.uid() 取得使用者 id 受 RLS 限制
secret key(sb_secret_ 開頭,舊稱 service_role key) service_role 繞過 RLS

一個請求能不能碰到資料,要過兩層:

  1. grant:這個角色能不能碰這張表。沒有 grant,直接得到 permission denied for table
  2. RLS policy:能碰這張表的話,能碰哪幾列。

每一條 RLS policy 有兩個條件:

  • USING:哪些既有的列,這個角色看得到、能修改或刪除(作用在 SELECT、UPDATE、DELETE)
  • WITH CHECK寫進去之後的列必須滿足什麼條件(作用在 INSERT、UPDATE)

UPDATE 的 policy 如果只寫 USING,Postgres 會拿同一個條件當 WITH CHECK,所以「把資料的擁有者改成別人」會被擋下(我在本機實測過)。真正的洞是 WITH CHECK 寫得太寬:寫成 with check (true),或只檢查角色是 authenticated。INSERT 沒有 USING 可以借,WITH CHECK 一定要自己檢查擁有者,這就是上面「只擋了讀,沒擋寫」的來源。

Supabase 2026 年的預設值變更

時程分兩段:

  • 2026-05-30 起建立的新專案public schema 裡新建的表,不再自動 grant 給 anonauthenticatedservice_role,前端查詢會得到 permission denied for table,要明確 grant 才能用。
  • 2026-10-30 起:所有既有專案也套用,之後新建的表要明確 grant,已經存在的表保留原本的 grant。

這讓「忘了開 RLS」的後果變小了,但沒有消失。RLS 預設還是關的,Supabase 的公告也寫明 grant 和 RLS 是兩層,grant 之後照樣需要 RLS。

我在本機重現過一個情況:前端出現 permission denied,錯誤訊息的 hint 寫著 GRANT SELECT ON public.your_table TO anon,AI 照著補上 grant,錯誤消失,功能正常,但那張表沒開 RLS,於是整張表又對外開放了。結果跟為了讓功能動起來而關掉 RLS 一樣。

grant 只給真正需要的角色。下面的範例沒有 grant 給 anon,沒登入的訪客連這張表都碰不到。

RLS 常見錯誤清單

錯誤 後果
沒啟用 RLS 有 grant 的角色可讀寫整張表(2026-05-30 之前建立的專案,anon 預設就有 grant)
啟用了但沒寫 policy,全部被擋,開發者為了「修好」而關掉 RLS 同上
出現 permission denied,照 hint 補了 grant,但沒開 RLS 同上
using (true) policy 指定的角色能讀所有資料
INSERT 的 with check 沒檢查擁有者(true,或只檢查有登入) 任何會員都能新增掛在別人名下的資料
UPDATE 寫了 with check (true) 可以把自己的資料轉給別人
用前端傳來的欄位或 user_metadata 判斷身分,而不是 auth.uid() 身分可偽造
建立 view 時沒有設定 security_invoker = true view 預設用建立者的權限執行,建立者通常是 postgres,繞過 RLS
在前端或可被公開呼叫的 function 使用 secret key(或舊的 service_role key) 完全繞過 RLS
Storage bucket 設為 public 拿到檔案網址的人都能下載,下載不受 RLS 限制

view 那一條最容易被忽略,因為表本身的 RLS 寫得好好的。我在本機實測:同一個沒登入的請求,直接查表得到空陣列,查建立在這張表上的 view 卻拿到全部資料;把 view 改成 security_invoker = true 之後,結果才跟直接查表一致。這個選項從 PostgreSQL 15 開始才有。

程式碼:RLS policy

alter table public.todo_lists enable row level security;

-- New Supabase projects no longer grant API roles access automatically.
-- Grant only what is needed: anonymous visitors get nothing here.
grant select, insert, update, delete on public.todo_lists to authenticated, service_role;

create policy "owners can read their lists"
  on public.todo_lists for select
  to authenticated
  using (user_id = (select auth.uid()));

create policy "owners can create lists for themselves"
  on public.todo_lists for insert
  to authenticated
  with check (user_id = (select auth.uid()));

create policy "owners can update their lists without transferring them"
  on public.todo_lists for update
  to authenticated
  using (user_id = (select auth.uid()))
  with check (user_id = (select auth.uid()));

create policy "owners can delete their lists"
  on public.todo_lists for delete
  to authenticated
  using (user_id = (select auth.uid()));

auth.uid() 外面多包一層 select,是 Supabase 官方文件建議的寫法:這樣 Postgres 在整個查詢只算一次,而不是每一列都算一次,表越大,省下的次數越多。Security Advisor 的 Performance 分頁會用 auth_rls_initplan 提醒你沒包的 policy。

service_role 的 grant 是給後端和測試腳本用的。在新專案裡,連 service_role 也要明確 grant,否則用 secret key 一樣會得到 permission denied

對照:應用層的多租戶隔離

我自己的補習班排課 SaaS 用的是 Laravel,沒有 RLS,但面對的是同一個問題:每一家補習班的資料不能被別家看到。做法是一個 trait 加上一個 global scope:所有業務資料表都有 tenant_id,查詢時自動加上「只看目前這家補習班」的條件。

RLS 是「資料庫層」的牆,global scope 是「應用層」的牆。應用層的牆有幾個已知的缺口,AI 寫程式時特別容易踩到:

失效情境 原因
Model::withoutGlobalScopes() 為了某個報表或後台功能方便,整個關掉
DB::table('courses')、raw SQL 不經過 Eloquent model,scope 不存在
Queue job、排程指令、外部 webhook 沒有登入的使用者,「目前是哪一家補習班」沒被設定

第三種最危險,因為它牽涉到一個預設值:沒有設定「目前的補習班」時,scope 要怎麼做?

我的實作選的是「不加任何條件」,理由是平台管理後台和指令列需要跨補習班查詢。代價是在 queue job 裡,scope 會靜靜地失效,查詢回傳的是所有補習班的資料。我的專案有一個 job 負責把事件送到各補習班設定的 webhook 網址,它能正確運作,靠的是 job 裡手寫的一行 tenant_id 條件。少了這一行,某家補習班新增學生的資料,會送到所有訂閱同一種事件的補習班。

這一行手寫的條件就是唯一的一道牆,哪天有人忘了寫,就跟資料表沒開 RLS 一樣。比較安全的預設是反過來:沒有設定補習班,就拒絕查詢

final class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        // Fail closed: a query without a tenant context is a bug, not "all tenants"
        if (! app()->bound(Tenant::class)) {
            throw new LogicException("No tenant bound while querying {$model->getTable()}.");
        }

        $builder->where($model->qualifyColumn('tenant_id'), app(Tenant::class)->id);
    }
}

需要跨補習班查詢的地方(平台後台、每日排程),明確寫 withoutGlobalScope(TenantScope::class)。這樣每一個例外都看得見,也能用下面的測試一次盤點出來。這跟 RLS「開了但沒寫 policy 就全部擋下」是同一個設計:預設拒絕,例外要明確開。

測試:Supabase 版,用兩個使用者

下面用 Vitest+supabase-js,建立 Alice 和 Bob 兩個使用者,逐一測試「看不到、改不了、塞不進去、轉不走」:

import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { beforeAll, describe, expect, it } from 'vitest'

// Point these at a local or staging project, never production
const url = process.env.SUPABASE_URL!
const publishableKey = process.env.SUPABASE_PUBLISHABLE_KEY!
const secretKey = process.env.SUPABASE_SECRET_KEY!

const noSession = { auth: { persistSession: false, autoRefreshToken: false } }
const admin = createClient(url, secretKey, noSession) // test setup only; bypasses RLS
const anon = createClient(url, publishableKey, noSession)

async function signUpAndSignIn() {
  const email = `rls-${crypto.randomUUID()}@example.test`
  const password = crypto.randomUUID()
  const { data, error } = await admin.auth.admin.createUser({ email, password, email_confirm: true })
  if (error) throw error

  const client = createClient(url, publishableKey, noSession)
  const { error: signInError } = await client.auth.signInWithPassword({ email, password })
  if (signInError) throw signInError

  return { client, id: data.user.id }
}

describe('todo_lists RLS', () => {
  let alice: { client: SupabaseClient; id: string }
  let bob: { client: SupabaseClient; id: string }
  let aliceListId: number
  let bobListId: number

  beforeAll(async () => {
    alice = await signUpAndSignIn()
    bob = await signUpAndSignIn()

    const { data, error } = await admin
      .from('todo_lists')
      .insert([
        { user_id: alice.id, title: 'alice list' },
        { user_id: bob.id, title: 'bob list' },
      ])
      .select('id, user_id')
    if (error) throw error

    aliceListId = data.find((row) => row.user_id === alice.id)!.id
    bobListId = data.find((row) => row.user_id === bob.id)!.id
  })

  it('lets owners read their own lists', async () => {
    const { data, error } = await alice.client.from('todo_lists').select('id')

    expect(error).toBeNull()
    expect(data).toEqual([{ id: aliceListId }])
  })

  it('shows nothing to visitors who are not signed in', async () => {
    const { data } = await anon.from('todo_lists').select('id')

    expect(data ?? []).toHaveLength(0)
  })

  it('hides lists owned by other members', async () => {
    const { data } = await alice.client.from('todo_lists').select('id').eq('id', bobListId)

    expect(data).toEqual([])
  })

  it('refuses to create a list in someone else’s name', async () => {
    const { error } = await alice.client
      .from('todo_lists')
      .insert({ user_id: bob.id, title: 'spam' })

    expect(error?.code).toBe('42501')
  })

  it('leaves other members’ lists unchanged', async () => {
    // RLS filters the row out, so this reports success while updating nothing
    await alice.client.from('todo_lists').update({ title: 'hacked' }).eq('id', bobListId)
    await alice.client.from('todo_lists').delete().eq('id', bobListId)

    const { data } = await admin.from('todo_lists').select('title').eq('id', bobListId).single()
    expect(data?.title).toBe('bob list')
  })

  it('refuses to transfer a list to someone else', async () => {
    const { error } = await alice.client
      .from('todo_lists')
      .update({ user_id: bob.id })
      .eq('id', aliceListId)

    expect(error?.code).toBe('42501')
  })
})

幾個細節:

  • 第一個測試是**「看得到」**。只測「看不到」的話,policy 寫錯導致連本人都看不到,測試也會通過。
  • 「沒登入看不到」用 data ?? []。舊專案有 grant 給 anon,會得到空陣列;新專案沒有 grant,會得到 permission denieddatanull。兩種都代表擋下了。
  • 修改和刪除別人的資料,要回頭用 admin 查,原因就是前面說的「成功但一筆都沒改」。
  • secret key 只放在測試環境,而且這組測試只能連本機或測試用的專案,絕對不要連正式環境。

我在本機用 PostgreSQL 17+PostgREST 模擬 Supabase 的角色實際跑過:上面的 policy 6 個測試全部通過;再故意把 policy 改壞(關掉 RLS、INSERT 只檢查有登入、SELECT 改成 using (true)、UPDATE 改成 with check (true)),每一種都至少有一個測試失敗。

測試:Laravel 版,盤點每一個繞道

Laravel 這邊,除了一般的跨租戶測試,還要盤點「哪些地方繞過了 scope」:

it('bypasses tenant scoping only in reviewed files', function () {
    // Every entry here needs a reason in code review
    $reviewed = [
        'app/Http/Controllers/Platform/ImpersonateController.php',
    ];

    $bypassing = collect(File::allFiles(app_path()))
        ->filter(fn ($file) => preg_match(
            '/withoutGlobalScopes?\(|DB::(table|select|statement|unprepared)\(/',
            $file->getContents(),
        ))
        ->map(fn ($file) => str($file->getPathname())->after(base_path().'/')->value())
        ->diff($reviewed)
        ->values()
        ->all();

    expect($bypassing)->toBe([]);
});

AI 新增了一個 withoutGlobalScopes()DB::table(),這個測試就會失敗,逼你決定:這個繞道合理嗎?合理的話加進清單,並在 code review 說明原因。我用同樣的規則掃自己的專案,找到 5 個檔案、9 處繞過 scope,每一處都得回答「為什麼可以」。

為什麼不用 Pest 的 arch 測試(->not->toUse('Illuminate\Support\Facades\DB'))?我實測後發現它不適合這件事:

  • 它會擋掉 DB::transaction(),但交易是正常需求,我的專案就有兩處。
  • 它抓不到 \DB::table() 這種別名寫法,把 'DB' 加進清單也一樣。
  • 它看不到 withoutGlobalScopes(),因為那是方法呼叫,不是類別相依。

「沒有設定補習班就拒絕」的 scope 本身,也用兩個測試鎖住:

beforeEach(function () {
    $this->tenantA = Tenant::create(['name' => 'A']);
    $this->tenantB = Tenant::create(['name' => 'B']);

    Course::withoutGlobalScope(TenantScope::class)->insert([
        ['tenant_id' => $this->tenantA->id, 'name' => 'A course'],
        ['tenant_id' => $this->tenantB->id, 'name' => 'B course'],
    ]);
});

it('scopes queries to the bound tenant', function () {
    app()->instance(Tenant::class, $this->tenantA);

    expect(Course::pluck('name')->all())->toBe(['A course']);
});

it('refuses to query without a tenant', function () {
    Course::count();
})->throws(LogicException::class);

以上 Laravel 程式碼在 Laravel 13.32+Pest 4.7 實際跑過。

對應標準

  • CWE-862:Missing Authorization
  • CWE-1188:Initialization of a Resource with an Insecure Default
  • OWASP API Security Top 10 2023:API1 Broken Object Level Authorization
  • OWASP ASVS 5.0:8.2.2(L1,資料層級的存取只開放給對該筆資料有明確權限的人)、8.3.1(L1,授權規則在受信任的一端強制執行,不依賴使用者能操控的前端程式)、8.4.1(L2,多租戶應用程式要有跨租戶的控制,任何操作都不能影響沒有權限的租戶)

【攔截點】

  • 最早該在這裡攔下:需求與設計。選擇「前端直連資料庫」的那一刻,就決定了授權全部交給資料庫的權限規則(RLS 或 Security Rules);每新增一張表,grant 和 policy 都是設計的一部分,要跟著權限表一起寫。
  • 漏掉時的下一道網:驗證(用公開金鑰直查、兩個使用者的 RLS 測試、跨租戶測試、繞道盤點測試);發布(第 25 天上線檢查、Security Advisor)。
  • 要問的問題(思考模型:身分與權限、預設值與依賴):這張表在沒有寫任何 policy 時,預設是什麼狀態?沒有設定「目前的使用者/租戶」時,查詢會回傳什麼?

【劃重點】

省掉後端,不代表省掉檢查。前端能直接連資料庫時,資料庫的權限規則就是唯一的一道牆。每一道牆都要問:沒設定的時候,預設是開還是關?

參考資料


上一篇
D04 登入了,不代表有權限
系列文
AI 寫的程式,安全嗎?完工不是結束,是攻擊倒數的起點5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言